Skip to content

feat(gcloud): add Google Cloud credential provider#310

Open
dpup wants to merge 16 commits into
mainfrom
plan/gcloud-provider
Open

feat(gcloud): add Google Cloud credential provider#310
dpup wants to merge 16 commits into
mainfrom
plan/gcloud-provider

Conversation

@dpup
Copy link
Copy Markdown
Collaborator

@dpup dpup commented Apr 10, 2026

Summary

  • Add moat grant gcloud provider that authenticates gcloud CLI and Google client libraries inside containers via GCE metadata server emulation
  • Host daemon mints short-lived OAuth2 access tokens using Application Default Credentials and serves them through an emulated metadata endpoint — no long-lived credentials enter the container
  • Support --project, --key-file, --impersonate-service-account, and --scopes CLI flags
  • Wire into daemon persistence, proxy routing (metadata.google.internal + 169.254.169.254), and run manager
  • Handle direct (non-proxied) metadata requests for gcloud CLI's GCE detection, which bypasses HTTP_PROXY via GCE_METADATA_ROOT
  • Support multiple concurrent gcloud runs sharing the same credential profile
  • Add guide, reference docs, and changelog entry

Architecture

Follows the AWS provider pattern: host-side CredentialProvider with token caching → EndpointHandler serving GCE metadata routes → per-run handler attached via daemon RunContext → proxy intercepts metadata requests via HTTP_PROXY.

Key differences from header-injection providers (GitHub, Claude):

  • No static token stored — tokens minted on demand from host ADC
  • Metadata server emulation instead of header injection
  • Token cached with 5-minute refresh buffer

Direct metadata handling

gcloud CLI and Python's google-auth library use bare http.client for GCE metadata detection, bypassing HTTP_PROXY. The proxy handles these direct requests by:

  1. Detecting Metadata-Flavor: Google header on non-proxied requests
  2. Resolving the gcloud handler via the daemon registry (no auth token available)
  3. Returning the handler only when all registered gcloud runs share the same credential profile — different profiles return 403 to prevent cross-run credential leakage

Container environment

Sets GCE_METADATA_HOST, GCE_METADATA_ROOT, CLOUDSDK_CORE_ACCOUNT, GOOGLE_CLOUD_PROJECT, and CLOUDSDK_CORE_PROJECT to point the gcloud CLI and Google client libraries at the proxy's metadata emulator.

Followup

Endpoint provider abstraction to decouple AWS/gcloud-specific logic from core daemon/proxy packages: docs/plans/2026-04-14-endpoint-provider-abstraction-design.md

Test plan

  • Unit tests for credential provider (caching, refresh, context cancellation)
  • Unit tests for metadata endpoint handler (all routes, error classification)
  • Unit tests for grant config parsing and credential reconstruction
  • Unit tests for proxy routing (hostname, IP, no-handler fallback)
  • Unit tests for direct metadata routing (ping, path, flavor header guard)
  • Unit tests for normalizeSAPath (email normalization, passthrough, recursive)
  • Unit tests for FindGCloudHandler (single run, same profile, different profiles, mixed, no gcloud)
  • Persistence round-trip test with GCloudConfig
  • go build ./... passes
  • make test-unit passes (pre-existing env-dependent failures in container/deps only)
  • make lint clean for all changed files
  • E2E: moat grant gcloud --project <p> + moat run --grant gcloud + gcloud projects list inside container

dpup added 10 commits April 8, 2026 19:27
- Wire --project, --key-file, --impersonate-service-account, --scopes
  flags in grant.go CLI (previously documented but not implemented)
- Fix EndpointProvider detection: use string match for AWS to avoid
  gcloud credentials being parsed as AWS config
- Add /computeMetadata/ path check for 169.254.169.254 routing to
  prevent AWS IMDS conflict
- Return defensive copy from Scopes() to prevent slice aliasing
- Use exec.CommandContext in detectProject() for cancellation support
- Extract DefaultEmail constant, handle zero-expiry tokens in metadata
- Fix stale GCE_METADATA_HOST references in doc comments
- Fix project detection order description in guide
@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 5m 2s —— View job


Code Review

  • Read changed files
  • Review provider implementation
  • Review endpoint handler
  • Review proxy routing
  • Review daemon wiring
  • Review docs and tests
  • Post final review

Overall this is a solid implementation that follows the AWS provider pattern faithfully. The architecture (host-side ADC → token cache → metadata emulation via proxy) is correct, the tests are thorough, and the daemon persistence/restore path is wired in consistently with the existing AWS provider.

Five issues worth addressing, roughly in order of priority:

strings.HasPrefix for 169.254.169.254 is too broad

internal/proxy/proxy.go:1130strings.HasPrefix(r.Host, "169.254.169.254") matches any host whose string representation starts with those digits, including the unlikely but technically valid 169.254.169.254.evil.com. See inline comment for the one-line fix.

CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE is not a documented env var

internal/run/manager.go:1035 — This variable doesn't appear in the gcloud Python SDK source or official docs. In practice it's harmless (containers don't have host gcloud config mounted), but it's in the reference docs as if it's real. Either verify it works, replace it with a documented alternative, or drop it.

Email is never populated for impersonation runs

internal/providers/gcloud/grant.go:107MetaKeyEmail is written to the metadata but never set during the grant flow, so the endpoint always serves default@moat.local. When --impersonate-service-account is used, the SA email is already known at grant time and could be stored. Without it, gcloud auth list inside the container is misleading.

classifyError string-matches on internal SDK messages

internal/providers/gcloud/endpoint.go:154 — The "could not find default credentials" and "oauth2: cannot fetch token" strings are not part of a stable API. Worth adding a comment to audit these when the golang.org/x/oauth2 dep is bumped. Low priority, but the fallback message already tells users where to look.

GetToken holds the write mutex across a network round-trip

internal/providers/gcloud/credential_provider.go:95 — All concurrent metadata requests serialise on the mutex for the duration of token refresh. Low priority for this use case, but worth a singleflight if the pattern gets copied.

Minor doc note

The guide (docs/content/guides/14-gcloud.md) doesn't mention the --scopes flag at all. It's in the reference, but a brief mention in the "Grant a credential" section would help users who need non-default scopes discover it.

Comment thread internal/proxy/proxy.go Outdated
// For 169.254.169.254, also check the path to avoid intercepting AWS IMDS
// requests when both gcloud and aws grants are active on the same run.
if r.Host == "metadata.google.internal" ||
(strings.HasPrefix(r.Host, "169.254.169.254") && strings.HasPrefix(r.URL.Path, "/computeMetadata/")) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strings.HasPrefix(r.Host, "169.254.169.254") is too broad — it matches any host that starts with that prefix, including 169.254.169.254.evil.com. Use an exact match plus a port-suffix variant:

Suggested change
(strings.HasPrefix(r.Host, "169.254.169.254") && strings.HasPrefix(r.URL.Path, "/computeMetadata/")) {
(( r.Host == "169.254.169.254" || strings.HasPrefix(r.Host, "169.254.169.254:")) && strings.HasPrefix(r.URL.Path, "/computeMetadata/")) {

Low practical impact (the path check and Metadata-Flavor header guard both fire), but it's semantically wrong.

Comment thread internal/run/manager.go Outdated
"GOOGLE_CLOUD_PROJECT="+r.GCloudCredentialProvider.ProjectID(),
"CLOUDSDK_CORE_PROJECT="+r.GCloudCredentialProvider.ProjectID(),
// Prevent gcloud from trying to read local credential files.
"CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE=true",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE doesn't appear in the gcloud Python SDK source or official environment variable docs. The closest real ones are CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE and GOOGLE_APPLICATION_CREDENTIALS.

In practice this is harmless — containers don't have ~/.config/gcloud/ mounted, so the gcloud CLI falls back to the metadata server regardless. But if the variable is a no-op it should be removed (the behaviour is already correct without it), or replaced with the correct variable name.


ui.Infof("Using Google Cloud project: %s", cfg.ProjectID)

cred := &provider.Credential{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cfg.Email field is never populated during the grant flow, so MetaKeyEmail is never written. The metadata endpoint will always serve default@moat.local for the email, even when impersonating a real service account.

gcloud auth list inside the container will show default@moat.local rather than the impersonated SA address, which could be confusing. At a minimum, when --impersonate-service-account is set, the provided email is known:

Suggested change
cred := &provider.Credential{
cred := &provider.Credential{
Provider: "gcloud",
Token: "", // no static token; tokens are minted on demand
CreatedAt: time.Now(),
Metadata: map[string]string{
MetaKeyProject: cfg.ProjectID,
MetaKeyScopes: strings.Join(cfg.Scopes, ","),
},
}
if cfg.ImpersonateSA != "" {
cred.Metadata[MetaKeyImpersonate] = cfg.ImpersonateSA
cred.Metadata[MetaKeyEmail] = cfg.ImpersonateSA // SA email is known
}

}

// classifyError returns a user-friendly error message for common token errors.
func classifyError(err error) string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classifyError matches against error message strings from the Google SDK. These strings are not part of any stable API and could silently break with a dependency update, turning the helpful messages into the generic fallback.

It's not easy to use errors.As/errors.Is here since the Google SDK wraps errors opaquely. Consider at least testing the error strings with strings.Contains(strings.ToLower(msg), ...) to be case-insensitive, and/or adding a comment that these need to be audited when golang.org/x/oauth2 is bumped.

return nil, ctx.Err()
}

p.mu.Lock()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutex is held across the p.source.Token() call (line 103), which can involve a network round-trip to refresh OAuth tokens. This serialises all concurrent metadata requests for the duration of the refresh. For a metadata emulation server that can receive burst requests from both the gcloud CLI and Go client libraries simultaneously, this could cause noticeable latency spikes.

Consider a singleflight group so that concurrent callers that race past the cache check all wait on the single in-flight refresh instead of queueing behind the mutex:

var g singleflight.Group

func (p *CredentialProvider) GetToken(ctx context.Context) (*oauth2.Token, error) {
    if ctx.Err() != nil {
        return nil, ctx.Err()
    }
    p.mu.RLock()
    if p.cached != nil && p.cached.Valid() && time.Now().Add(credentialRefreshBuffer).Before(p.cached.Expiry) {
        tok := p.cached
        p.mu.RUnlock()
        return tok, nil
    }
    p.mu.RUnlock()

    v, err, _ := p.g.Do("token", func() (any, error) {
        tok, err := p.source.Token()
        if err == nil {
            p.mu.Lock()
            p.cached = tok
            p.mu.Unlock()
        }
        return tok, err
    })
    ...
}

This is a low-priority improvement for the current use case, but worth noting.

- Use exact host match for 169.254.169.254 instead of prefix match
- Remove CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE (not a real gcloud env var)
- Set email from impersonated SA when --impersonate-service-account is used
- Add fragility comment to classifyError string matching
@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 45s —— View job


Code Review

  • Read changed files
  • Review credential provider implementation
  • Review endpoint handler
  • Review proxy routing
  • Review daemon wiring
  • Review docs and tests

All five issues from the previous review have been addressed. The proxy host-matching fix, removal of the undocumented env var, email population for impersonation, and the classifyError audit comment are all in. The write-mutex across network I/O is still present but was explicitly deferred as low priority.

Three new issues below, one of which is a real functional bug.


Bug: relative key file path breaks on daemon restore

internal/providers/gcloud/grant.go:74-76

if v, ok := ctx.Value(ctxKeyKeyFile).(string); ok && v != "" {
    cfg.KeyFile = v
}

The path is stored as-is in the credential metadata and later passed to buildTokenSourceos.ReadFile(cfg.KeyFile). At grant time, this works because the CLI process is in the user's cwd. On daemon restore (RestoreRunsNewCredentialProvider), os.ReadFile resolves relative to wherever the daemon process is running, not the user's original directory. The file will silently not be found and the provider creation will fail.

Fix — resolve to absolute before storing:

if v, ok := ctx.Value(ctxKeyKeyFile).(string); ok && v != "" {
    if abs, err := filepath.Abs(v); err == nil {
        cfg.KeyFile = abs
    } else {
        cfg.KeyFile = v
    }
}

Fix this →


Minor: service-accounts listing duplicates default@moat.local

internal/providers/gcloud/endpoint.go:102-104

case "/computeMetadata/v1/instance/service-accounts/", ...
    fmt.Fprintf(w, "default/\n%s/\n", h.email)

When no real email is known, h.email is "default@moat.local", so the listing becomes:

default/
default@moat.local/

gcloud auth list follows default@moat.local/ as a distinct account and shows it alongside default. The real GCE metadata server only lists accounts that exist. Guard with a check:

if h.email != DefaultEmail {
    fmt.Fprintf(w, "default/\n%s/\n", h.email)
} else {
    fmt.Fprint(w, "default/\n")
}

Fix this →


Docs: --scopes not mentioned in guide

docs/content/guides/14-gcloud.md

The guide shows --project, --impersonate-service-account, and --key-file but never --scopes. Users who need non-default scopes (e.g., BigQuery, Cloud Storage without the umbrella cloud-platform scope) won't discover it. A sentence in the "Grant a credential" section would cover it — e.g.:

Use --scopes to request a narrower or custom set of OAuth2 scopes (default: https://www.googleapis.com/auth/cloud-platform).


Otherwise the implementation is solid. The architecture matches the AWS provider pattern cleanly, the proxy routing conditions are correct, the credential is never stored on disk, and the test coverage is thorough across all major paths.

dpup added 3 commits April 14, 2026 19:30
gcloud CLI's GCE detection bypasses HTTP_PROXY (uses ReadNoProxy with
ProxyHandler({})) and checks GCE_METADATA_ROOT instead of GCE_METADATA_HOST.
This meant the metadata emulator was unreachable during GCE detection, causing
gcloud to conclude it was not running on GCE.

Three fixes:

1. Direct metadata handler in proxy: handle requests arriving without
   Proxy-Authorization by matching Metadata-Flavor header + /computeMetadata/
   path. The daemon wires a resolver that finds the gcloud handler from the
   registry without requiring a proxy auth token.

2. Container env vars: set GCE_METADATA_ROOT (for gcloud CLI) and
   GCE_METADATA_HOST (for google-auth client libraries) pointing directly
   at the proxy, plus CLOUDSDK_CORE_ACCOUNT for the active account.

3. Endpoint handler: normalize email-based service account paths to
   "default" and handle ?recursive=true queries that gcloud uses to
   fetch all account details in one request.
Prevent cross-run credential leakage in FindGCloudHandler by returning
nil when multiple gcloud runs are registered. Add table-driven tests for
normalizeSAPath covering email-based identifiers, passthrough cases, and
recursive query integration. Use gcloud.DefaultEmail constant instead of
inline string in manager.go.
Previously FindGCloudHandler refused to serve when multiple runs had
gcloud configured, even if they shared the same credentials. Now it
compares the credential profile across runs — same profile means
identical credentials, so any handler is equivalent. Different profiles
still return nil to prevent cross-run credential leakage.

Adds Profile field to GCloudConfig and populates it from
credential.ActiveProfile during run creation.
@claude
Copy link
Copy Markdown

claude Bot commented Apr 14, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 4m 23s —— View job


Code Review

  • Read changed files
  • Review credential provider implementation
  • Review endpoint handler
  • Review proxy routing and daemon wiring
  • Check if previous review issues were addressed
  • Review docs and tests

Good progress since the last review. The proxy host-matching fix, removal of the undocumented env var, impersonation email population, and classifyError audit comment are all in. Three issues from previous rounds are still open, plus one new bug.

Still open: relative key file path breaks on daemon registration and restore

internal/providers/gcloud/grant.go:75

The key file path is stored as-is from the CLI flag and ends up in cred.Metadata[MetaKeyKeyFile]. This value flows into GCloudConfig.KeyFile in daemon/server.go:260 and daemon/persist.go:263, and is then passed to NewCredentialProvideros.ReadFile(cfg.KeyFile) inside the daemon process. The daemon's working directory is not the user's cwd, so relative paths like ./key.json silently fail to open.

There are two failure points, not one: handleRegisterRun (first registration) and RestoreRuns (after daemon restart). Both call os.ReadFile with the path as stored.

Fix in grant.go before storing in cred.Metadata:

if v, ok := ctx.Value(ctxKeyKeyFile).(string); ok && v != "" {
    if abs, err := filepath.Abs(v); err == nil {
        cfg.KeyFile = abs
    } else {
        cfg.KeyFile = v
    }
}

Fix this →


Still open: service-accounts listing duplicates default@moat.local

internal/providers/gcloud/endpoint.go:117

case "/computeMetadata/v1/instance/service-accounts/",
    "/computeMetadata/v1/instance/service-accounts":
    fmt.Fprintf(w, "default/\n%s/\n", h.email)

When no real SA email is known, h.email is "default@moat.local", producing:

default/
default@moat.local/

gcloud auth list follows default@moat.local/ as a separate account path and shows it alongside default. The existing test at line 146 only covers the case with a real email — there's no test for the DefaultEmail path. The serveRecursive handler has the same implicit issue (includes h.email unconditionally).

Fix:

case "/computeMetadata/v1/instance/service-accounts/",
    "/computeMetadata/v1/instance/service-accounts":
    if h.email != DefaultEmail {
        fmt.Fprintf(w, "default/\n%s/\n", h.email)
    } else {
        fmt.Fprint(w, "default/\n")
    }

Fix this →


Still open: --scopes not mentioned in the guide

docs/content/guides/14-gcloud.md

--project, --impersonate-service-account, and --key-file all have dedicated subsections, but --scopes is nowhere in the guide. Users who need non-default scopes (e.g., BigQuery-only, Pub/Sub, or a custom scope) won't find it.

A single sentence in the "Grant a credential" section covers it:

Use --scopes to override the default OAuth2 scope (https://www.googleapis.com/auth/cloud-platform) with a comma-separated list of specific scopes.


New: token cache skips for tokens with zero expiry

internal/providers/gcloud/credential_provider.go:99

if p.cached != nil && p.cached.Valid() && time.Now().Add(credentialRefreshBuffer).Before(p.cached.Expiry) {
    return p.cached, nil
}

oauth2.Token.Valid() returns true when Expiry.IsZero() (token never expires). But time.Now().Add(credentialRefreshBuffer).Before(time.Time{}) is always false because no time is before the zero instant. Any token with a zero expiry bypasses the cache and calls p.source.Token() on every metadata request.

In practice, all Google token sources return tokens with expiry set (typically 1 hour), so this won't bite normal ADC or key-file flows. But it's semantically wrong and would serialize all metadata requests behind a network call if triggered.

Fix:

if p.cached != nil && p.cached.Valid() &&
    (p.cached.Expiry.IsZero() || time.Now().Add(credentialRefreshBuffer).Before(p.cached.Expiry)) {
    return p.cached, nil
}

Fix this →


Previously deferred (acknowledged in last round)

Write mutex held across network I/O (credential_provider.go:95–103) — still present, still low priority for this use case. Noting it remains so it doesn't get lost.


The rest of the implementation is in good shape: proxy routing conditions are correct (metadata.google.internal exact match + 169.254.169.254 with port-suffix check + path guard), FindGCloudHandler correctly handles multi-run profile isolation, the daemon registration and restore paths are symmetric, and the test coverage across the provider, endpoint, and proxy layers is solid.

- Resolve key file path to absolute before storing in credential
  metadata so relative paths work when the daemon reads them from a
  different working directory
- Fix token cache for zero-expiry tokens: treat Expiry.IsZero() as
  indefinitely valid instead of bypassing cache on every request
- Only list email-based service account in metadata listing when a real
  email is known; omit the synthetic default@moat.local to avoid
  duplicate entries in gcloud auth list
- Add --scopes documentation to the gcloud guide
@claude
Copy link
Copy Markdown

claude Bot commented Apr 14, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 42s —— View job


Code Review

  • Read changed files
  • Review credential provider implementation
  • Review endpoint handler
  • Review proxy routing and daemon wiring
  • Check if previous review issues were addressed
  • Review docs and tests
  • Post final review

All issues from the previous three review rounds have been addressed. The implementation is solid and ready to merge with one minor issue flagged below.

Resolved from previous rounds

  • strings.HasPrefix for 169.254.169.254 — fixed with exact match + port-prefix check
  • CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE — removed
  • Email not populated for impersonation — fixed; SA email is now stored and served correctly
  • classifyError audit comment — added
  • Relative key file path — fixed with filepath.Abs in grant.go:77–80
  • Service-accounts listing duplicates DefaultEmail — fixed with guard and a new test at endpoint_test.go:285–303
  • --scopes missing from guide — added as a dedicated subsection in docs/content/guides/14-gcloud.md:53–60
  • Token cache skips for zero-expiry tokens — fixed with p.cached.Expiry.IsZero() guard at credential_provider.go:100

Still deferred (acknowledged)

Write mutex held across network I/O (credential_provider.go:95–103) — still present, still low priority for this use case.


Minor: --key-file + --impersonate-service-account combination silently ignores impersonation

internal/providers/gcloud/credential_provider.go:56–79

When both --key-file and --impersonate-service-account are specified, buildTokenSource uses the key file directly and silently drops the impersonation target:

// Key file takes precedence.
if cfg.KeyFile != "" {
    // ... uses key file directly, ImpersonateSA is never consulted
    return creds.TokenSource, nil
}

// Impersonation wraps the default credentials.
if cfg.ImpersonateSA != "" {
    // ... only reached if KeyFile is empty
}

Both values are stored in the credential metadata (and will be faithfully restored), so a user who runs:

moat grant gcloud --key-file svc.json --impersonate-service-account deploy@project.iam.gserviceaccount.com

…will get a token from the key file as-is, with no impersonation. The metadata will show an impersonation target that has no effect at runtime.

In enterprise setups this is actually the expected combination ("use this key as the caller to impersonate that SA"), which the google/impersonate package supports via the Subject and Delegates fields. That said, adding that semantics is new scope. At minimum, a comment noting the precedence rule and what the combination means would prevent confusion:

// Key file takes precedence. If ImpersonateSA is also set, it is ignored —
// impersonation using a key file as the caller is not currently supported.
if cfg.KeyFile != "" {

Fix this →


Correctness check: all other paths confirmed

  • Proxy routingmetadata.google.internal exact match and 169.254.169.254 with port-suffix check and /computeMetadata/ path guard are correct
  • Direct metadata routingresolveDirectGCloudHandler falls through to 407 (not 403) when profiles differ, but the credential isolation is still effective; only the PR description text overstated the error code
  • normalizeSAPath — handles all cases correctly; query strings are stripped via r.URL.Path before the switch; test coverage is thorough
  • FindGCloudHandler profile isolationProfile is populated from credential.ActiveProfile at run/manager.go:715, so the multi-run safety logic actually fires
  • Daemon registration and restore — symmetric; both handleRegisterRun and RestoreRuns create NewCredentialProvider with the same config struct; both log and skip (warn) if provider creation fails without blocking the run
  • serveRecursive — returns h.email in the JSON detail, which is DefaultEmail when identity is unknown; this is inherent to the ADC path (no API call to resolve the identity) and doesn't cause duplicate listing issues, only the service-accounts listing endpoint was the concern
  • Token cache — zero-expiry guard, refresh buffer, and context-cancel check are all correct

@dpup
Copy link
Copy Markdown
Collaborator Author

dpup commented Apr 14, 2026

Addressed all four items from the latest review in aee6aaf:

  1. Relative key file pathfilepath.Abs before storing in credential metadata. Both handleRegisterRun and RestoreRuns paths now get absolute paths.
  2. Zero-expiry token cache — Added p.cached.Expiry.IsZero() guard so tokens without expiry are treated as indefinitely valid.
  3. SA listing duplication — Only list email-based account when h.email != DefaultEmail. Added test for the DefaultEmail listing path.
  4. --scopes in guide — Added "Custom scopes" subsection with example.

Mutex-across-network-IO remains deferred as before (low priority for this use case).

@claude
Copy link
Copy Markdown

claude Bot commented Apr 14, 2026

Claude finished @dpup's task in 3m 50s —— View job


Code Review

  • Read changed files
  • Review credential provider implementation
  • Review endpoint handler
  • Review proxy routing and daemon wiring
  • Check previous review issues were addressed
  • Review docs and tests
  • Post final review

All nine issues from the previous four rounds have been addressed. The implementation is clean and ready to merge.

Resolved from previous rounds ✓

  • strings.HasPrefix for 169.254.169.254 — fixed with exact match + port-suffix check
  • CLOUDSDK_AUTH_DISABLE_CREDENTIALS_FILE — removed
  • Email not populated for impersonation — SA email now stored and served correctly
  • classifyError audit comment — added
  • Relative key file path — filepath.Abs in grant.go:77–80
  • Service-accounts listing duplicates DefaultEmail — guarded; test added at endpoint_test.go:285–303
  • --scopes missing from guide — added as "Custom scopes" subsection
  • Zero-expiry token cache — Expiry.IsZero() guard at credential_provider.go:100
  • --key-file + --impersonate-service-account combination — comment documents the precedence

Still deferred (acknowledged)

Write mutex held across network I/O (credential_provider.go:96–111) — still present, still low priority for this use case.


Minor: numeric project ID "0" not listed in Limitations

docs/content/guides/14-gcloud.md:100

The endpoint at /computeMetadata/v1/project/numeric-project-id always returns "0" (endpoint.go:129). Some older SDKs and billing/quota APIs reference the numeric project ID rather than the string ID. Users won't find out until they hit a 403 or unexpected behaviour at runtime.

The comment in code already says "not available; return 0" — it just needs a corresponding entry in the guide's Limitations section:

Numeric project ID — The metadata emulator always returns 0 for /computeMetadata/v1/project/numeric-project-id. APIs that require the numeric project ID (some older GCP services) will not work.

Fix this →


Correctness check: all other paths confirmed

  • Credential provider — cache hit/miss/refresh logic is correct; Expiry.IsZero() guard handles never-expiring tokens; GetToken is threadsafe (mutex held across refresh, low concurrency for this use case)
  • Endpoint handlernormalizeSAPath handles all path variants correctly; Metadata-Flavor enforcement is in place; serveRecursive includes h.email (harmless when it's DefaultEmail, not a duplicate-listing issue); serveToken correctly clamps negative expires_in to 0
  • Proxy routingmetadata.google.internal exact host match and 169.254.169.254 with port-suffix check and /computeMetadata/ path guard are correct; won't intercept AWS IMDS traffic
  • Direct metadata routingresolveDirectGCloudHandler checks p.gcloudHandler (legacy mode) then p.gcloudDirectResolver (daemon mode); FindGCloudHandler profile isolation is correctly wired with credential.ActiveProfile at run/manager.go:715; profile mismatch falls through to 407, not 403 (PR description overstates it, code is fine)
  • Daemon registration and restore — symmetric; handleRegisterRun and RestoreRuns both construct NewCredentialProvider with the same config; filepath.Abs is applied before storage so both paths receive absolute paths
  • PersistenceGCloudConfig round-trip confirmed in persist_test.go:32,87
  • FindGCloudHandler edge cases — nil handler runs are excluded; nil GCloudConfig with non-nil handler can't happen in production (handler is only set inside if req.GCloudConfig != nil); default (empty) profile treated identically to matching profiles
  • Docs — guide covers --project, --impersonate-service-account, --key-file, --scopes; reference table and flags table are accurate; changelog entry is present; CLAUDE.md conventions followed throughout

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant